Entity Framework 中 DateTime 時區問題與解決方案
雖然許多專案僅在台灣環境中運行,不需要考慮時區問題,但隨著雲端環境的普及,而很多雲端時區都是定在國際標準時間(UTC +0 時區),所以也開始需要注意這個問題。
我一直知道 DateTime 的 UTC 格式 可能存在陷阱,因此在處理時區問題時,我通常會盡量使用 DateTimeOffset。由於這幾天遇到了一個相關的情境,所以就稍微查資料,並記錄一下。
有同事向我反映,他的專案已經和前端約定使用 UTC 的時間,但在將從資料庫取得的 DateTime 資料傳給前端時,發現時間少了 8 個小時。為了解決這個問題,他使用 ToString() 方法將時間格式化為 yyyy-MM-ddTHH:mm:ssZ。
我當時疑惑地問他,為什麼要在時間字串的末尾加上 Z。他回應說這樣時間才不會少 8 小時。我去查一下,根據 Wiki 上的「ISO 8601 」說明,Z 表示 UTC +0 時區。
本來想要幫他優化這部分處理,認為應該要在 JsonSerializerOptions.Converters,裡變更 DateTime 型別的處理。但後來想想,使用 DateTime 做 UTC +0 的專案肯定不少,像知名框架 ABP.IO,就是使用 DateTime 型別,ASP.NET Core 應該不至於在處理格式時,沒注意到這點。上網查一下,DateTime 如果是 UTC 格式 的話,會有 Z 結尾沒錯,就做以下測試:
DateTime localTime = new DateTime(2024, 8, 14, 8, 0, 0, DateTimeKind.Local);
DateTime utcTime = new DateTime(2024, 8, 14, 8, 0, 0, DateTimeKind.Utc);
DateTime unspecifiedTime = new DateTime(2024, 8, 14, 8, 0, 0, DateTimeKind.Unspecified);
Console.WriteLine("Local:" + localTime.ToString("O"));
Console.WriteLine("UTC:" + utcTime.ToString("O"));
Console.WriteLine("Unspecified:" + unspecifiedTime.ToString("O"));產生結果如下:
Local:2024-08-14T08:00:00.0000000+08:00
UTC:2024-08-14T08:00:00.0000000Z
Unspecified:2024-08-14T08:00:00.0000000再對比我同事的這句話,感覺破案了。
但在將從資料庫取得的
DateTime資料傳給前端時,發現時間少了 8 小時。
DateTime 的時區格式問題
DateTime 這個型別有一個 Kind 屬性,用於表示時間的來源,共有以下列舉值:
| 值 | 屬性名稱 | 說明 |
|---|---|---|
| 0 | Unspecified | 未指定 |
| 1 | Utc | Coordinated Universal Time (UTC) |
| 2 | Local | 本機時間 |
而不清楚 Kind 格式的情況下,使用 ToLocalTime() 或 ToUniversalTime() 來切換時間,產生來的時間就會不如預期。
以下是測試程式碼:
DateTime utcNow = DateTime.UtcNow;
DateTime now = DateTime.Now;
Print("原始時間:");
PrintNow("Local", now);
PrintNow("Utc", utcNow);
Console.WriteLine();
Print("切換 Kind 為 Local");
PrintTime(DateTime.SpecifyKind(now, DateTimeKind.Local));
Console.WriteLine();
Print("切換 Kind 為 Utc:");
PrintTime(DateTime.SpecifyKind(now, DateTimeKind.Utc));
Console.WriteLine();
Print("切換 Kind 為 Unspecified:");
PrintTime(DateTime.SpecifyKind(now, DateTimeKind.Unspecified));
void Print(string str) {
Console.WriteLine(str);
}
void PrintNow(string title, DateTime dateTime) {
Print($"{title}:{dateTime:O}, Kind:{dateTime.Kind}");
}
void PrintTime(DateTime dateTime) {
Print($"Original:{dateTime:O}, Kind:{dateTime.Kind}");
DateTime local = dateTime.ToLocalTime();
Print($"Local:{local:O}, Kind:{local.Kind}");
DateTime utc = dateTime.ToUniversalTime();
Print($"Utc:{utc:O}, Kind:{utc.Kind}");
}產生結果如下:
原始時間:
Local:2024-08-15T10:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T02:35:48.8421977Z, Kind:Utc
切換 Kind 為 Local
Original:2024-08-15T10:35:48.8422172+08:00, Kind:Local
Local:2024-08-15T10:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T02:35:48.8422172Z, Kind:Utc
切換 Kind 為 Utc:
Original:2024-08-15T10:35:48.8422172Z, Kind:Utc
Local:2024-08-15T18:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T10:35:48.8422172Z, Kind:Utc
切換 Kind 為 Unspecified:
Original:2024-08-15T10:35:48.8422172, Kind:Unspecified
Local:2024-08-15T18:35:48.8422172+08:00, Kind:Local
Utc:2024-08-15T02:35:48.8422172Z, Kind:Utc從結果可以看到:
- 當
Kind為Local時,呼叫ToLocalTime()不會改變時間。 - 當
Kind為Utc時,呼叫ToUniversalTime()也不會改變時間。 - 當
Kind為Unspecified時,由於無法確定時間的類型,呼叫ToLocalTime()時,系統會假設原本是 UTC 時間,並轉換為本機時間,因而增加時區偏移。相反地,呼叫ToUniversalTime()時,系統會假設原本是本機時間,並減去時區偏移。
也因此,ABP.IO 在使用 DateTime 時,有定義 IClock 介面,來將的Kind 進行修正,來避免預期外問題,以下節錄他的 Clock 程式碼,藉由比對設定的 Kind 與要標準化的時間的 Kind,來決定轉換結果,更具體的說明可參考官方文件「Timing」。
public virtual DateTime Normalize(DateTime dateTime) {
if (Kind == DateTimeKind.Unspecified || Kind == dateTime.Kind) {
return dateTime;
}
if (Kind == DateTimeKind.Local && dateTime.Kind == DateTimeKind.Utc) {
return dateTime.ToLocalTime();
}
if (Kind == DateTimeKind.Utc && dateTime.Kind == DateTimeKind.Local) {
return dateTime.ToUniversalTime();
}
return DateTime.SpecifyKind(dateTime, Kind);
}Entity Framework 使用 DateTime 的時區問題
如果資料表欄位使用 datetime、datetime2 等不包含時區的資料庫類型,在儲存資料時,由於這些型別無法儲存時區資訊,因此存進資料庫的時間並不包含時區資訊。但是,當 Entity Framework 將資料取出並對應到 DateTime 型別時,由於無法確定時間的 Kind,這時的 Kind 會是 Unspecified。因此,回傳給前端的時間值末尾不會包含 Z。
此時,正確的處理方式不是在回傳值時補上 Z,而是在從資料庫取出資料時,將 DateTime 型別的 Kind 轉換為 Utc。雖然 DateTime 在進行值比較時不會考慮 Kind,但在程式內的 DateTime.Kind 有多種可能的情況下,呼叫 ToLocalTime() 或 ToUniversalTime() 時,可能會導致預期外的結果。
解決方案
如果有在使用 Code First 的話,就會知道這時候是 ValueConverter 出馬的時候了。使用 Fluent API 在 OnModelCreating() 中定義 Entity 結構時,可以透過 HasConversion() 來處理資料寫入和讀取時的轉換。常見的用途包括 Enum、Enum Object 和時間的時區處理。詳細資訊可參考 Microsoft 的文件「值轉換」,這篇先針對此問題來說明。
可以藉由 HasConversion() 來進行以下處理:
- 在資料寫入時,若
DateTime的Kind不是Utc,則呼叫ToUniversalTime()進行轉換。 - 在取出資料時,將
DateTime的Kind設定為Utc。
具體程式碼如下:
modelBuilder.Entity<Test>(entity => {
entity.Property(x => x.TestDateTime)
.HasConversion(
v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc)
);
});也可以定義一個 UtcDateTimeValueConverter 類別來重複使用,具體程式碼如下:
public class UtcDateTimeValueConverter : ValueConverter<DateTime, DateTime> {
public UtcDateTimeValueConverter()
: base(v => ToDb(v), v => FromDb(v)) {
}
private static DateTime ToDb(DateTime dateTime) {
return dateTime.Kind == DateTimeKind.Utc ? dateTime : dateTime.ToUniversalTime();
}
private static DateTime FromDb(DateTime dateTime) {
return DateTime.SpecifyKind(dateTime, DateTimeKind.Utc);
}
}使用 UtcDateTimeValueConverter 進行轉換:
modelBuilder.Entity<Test>(entity => {
entity.Property(x => x.TestDateTime)
.HasConversion<UtcDateTimeValueConverter>();
});如果不想要每一個屬性都個別設定,可以用使用以下方式統一處理:
foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) {
foreach (IMutableProperty property in entityType.GetProperties()) {
if (property.ClrType == typeof(DateTime) || property.ClrType == typeof(DateTime?)) {
property.SetValueConverter(typeof(UtcDateTimeValueConverter));
}
}
}使用 Code First,DbContext 內容可以隨意定義,可以使用以上的作法。但如果是使用反向工程來產生 Entity 和 DbContext 的話,通常 DbContext 應該會包含以下程式碼:
public partial class MyDbContext : DbContext {
// 省略中...
protected override void OnModelCreating(ModelBuilder modelBuilder) {
// 省略 Entity 定義
OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
}此時,可以寫一個 Partial 類別來增加自定義設定,需注意 Namespace 必須與反向工程產生的 MyDbContext 的 Namespace 一致:
public partial class MyDbContext {
partial void OnModelCreatingPartial(ModelBuilder modelBuilder) {
foreach (IMutableEntityType entityType in modelBuilder.Model.GetEntityTypes()) {
foreach (IMutableProperty property in entityType.GetProperties()) {
if (property.ClrType == typeof(DateTime) || property.ClrType == typeof(DateTime?)) {
property.SetValueConverter(typeof(UtcDateTimeValueConverter));
}
}
}
}
}當然不寫 Partial 類別,而是另寫一個 DbContext 去繼承,然後程式使用自定義的 DbContext,我也不反對阿。
而在 .NET 6,又有一個更簡單的設定方式,ConfigureConventions(),詳請可參考 Microsoft 的 文件:
public partial class MyDbContext {
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) {
ArgumentNullException.ThrowIfNull(configurationBuilder);
configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeValueConverter>();
}由於 ConfigureConventions() 會在 OnModelCreating() 前執行,所以可用來定義預設值和設定慣例,如果想要覆蓋設定的部分,則適合定義在 OnModelCreatingPartial() 裡。
異動歷程
- 2024-08-15 初版文件建立。
